Fix iOS notification deep link to post (read trigger.payload)#264
Conversation
Tapping a Gumroad push notification on iOS opened the home tab instead
of the linked post. The cause was where the payload is read: for remote
pushes on iOS, expo-notifications leaves content.data null and puts the
APNs payload (installment_id, purchase_id, ...) under
request.trigger.payload. The handler only read content.data, so it never
found installment_id and fell back to the default tab.
consumeNotificationRoute now reads the payload from both
request.content.data (Android FCM data field / Expo push) and
request.trigger.payload (iOS remote), so the route resolves on both
platforms. Adds Sentry breadcrumbs at the cold-start and parse sites to
surface the payload shape if a future notification fails to route.
Verified end-to-end on the iOS simulator and Android emulator against
production data: tapping the notification opens /post/{id} and the
installment content loads (200) once purchase_id is included.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Greptile SummaryFixes iOS push notification deep-linking by reading the APNs payload from
Confidence Score: 4/5Safe to merge — the change is well-scoped to notification routing with no mutations to shared state or auth paths. The dual-payload fallback logic is correct and the new tests cover the iOS and Android cases. Two minor concerns: the Sentry mock is missing The type cast in Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Notification tapped] --> B[consumeNotificationRoute]
B --> C{response is null?}
C -- Yes --> D[return null]
C -- No --> E{identifier already handled?}
E -- Yes --> D
E -- No --> F[payloads = content.data + trigger.payload]
F --> G[Try content.data first]
G --> H{installment_id present?}
H -- Yes --> I[buildNotificationRoute]
I --> J[Add to handledIdentifiers]
J --> K[return route]
H -- No --> L[Try trigger.payload - iOS APNs]
L --> M{installment_id present?}
M -- Yes --> I
M -- No --> N[Sentry warning breadcrumb]
N --> D
|
| const request = response.notification.request as { | ||
| identifier: string; | ||
| content?: { data?: Record<string, any> | null }; | ||
| trigger?: { payload?: Record<string, any> | null } | null; | ||
| }; |
There was a problem hiding this comment.
Overly-broad type cast makes
content optional when the SDK guarantees it is always present
The cast widens request to a custom interface where content is optional (content?:). In the expo-notifications SDK, NotificationRequest.content is always defined — making it optional in the cast can mask TypeScript errors if future code accesses request.content without null-guarding. A safer approach is to cast only the trigger sub-field so the rest of the SDK-provided typing is preserved.
| const request = response.notification.request as { | |
| identifier: string; | |
| content?: { data?: Record<string, any> | null }; | |
| trigger?: { payload?: Record<string, any> | null } | null; | |
| }; | |
| const request = response.notification.request; | |
| const trigger = request.trigger as { payload?: Record<string, any> | null } | null | undefined; |
Summary
Tapping a Gumroad push notification on iOS opened the home tab instead of the linked post (follow-up to the cold-start work in #258).
Root cause: for remote pushes on iOS,
expo-notificationsleavesrequest.content.datanull and places the APNs payload (installment_id,purchase_id, …) underrequest.trigger.payload. The handler only readcontent.data, so it never foundinstallment_idand fell back to the default tab. (Verified against the real backend payload ingumroad—post_sendgrid_api.rbsends those keys top-level on iOS / in the FCMdatafield on Android.)Fix:
consumeNotificationRoutenow reads the payload from bothrequest.content.data(Android FCM data field / Expo push) andrequest.trigger.payload(iOS remote), so the route resolves on both platforms. Added Sentry breadcrumbs at the cold-start and parse sites to surface the payload shape if a future notification ever fails to route.Changes
components/use-push-notifications.ts— read payload fromcontent.dataandtrigger.payload; breadcrumb when no route is built.app/index.tsx— breadcrumb recording cold-start response/route.tests/components/use-push-notifications.test.ts— cover the iOStrigger.payloadshape, the Android FCM payload (withtag/messageignored), and multi-notification routing.Test plan
Verified end-to-end on iOS simulator and Android emulator against production data:
/post/{installment_id}(not the home tab).200) oncepurchase_idis included (matches the real push payload).tsc --noEmit,expo lint, andjest(200/200) all pass.🤖 Generated with Claude Code
Need help on this PR? Tag
/codesmithwith what you need. Autofix is disabled.Note
Low Risk
Narrow change to notification routing and observability; Android behavior stays on content.data first, with no auth or data-model changes.
Overview
Fixes iOS push notification deep links so tapping a notification opens the linked post instead of the default home tab.
consumeNotificationRoutenow resolves routing data fromrequest.content.data(Android / Expo) andrequest.trigger.payload(iOS remote APNs), trying each until a/post/{installment_id}route can be built. When parsing fails, it records a Sentry warning breadcrumb with both payload shapes; cold-start routing inapp/index.tsxadds an info breadcrumb for the last response and resolved route.Tests cover the iOS
trigger.payloadshape, Android FCM payloads with extratag/messagefields, and routing multiple distinct notifications.Reviewed by Cursor Bugbot for commit 2105b2a. Bugbot is set up for automated code reviews on this repo. Configure here.